/****************************************************************************/
/*  File:       PkgInstaller.java                                           */
/*  Author:     F. Georges                                                  */
/*  Company:    H2O Consulting                                              */
/*  Date:       2009-10-31                                                  */
/*  Tags:                                                                   */
/*      Copyright (c) 2009 Florent Georges (see end of file.)               */
/* ------------------------------------------------------------------------ */


package org.expath.pkg.repo;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import javax.xml.namespace.QName;
import javax.xml.stream.StreamFilter;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;

/**
 * ...
 *
 * TODO: Move protected methods to a helper class.
 *
 * @author Florent Georges
 * @date   2009-10-31
 */
class PkgInstaller
{
    PkgInstaller(Repository repo, UserInteractionStrategy interact)
    {
        myRepo = repo;
        myInteract = interact;
        myHandlers = new HashMap<String, PkgComponentHandler>();
        // TODO: The goal is to be completely decoupled from stuff like handlers
        // for Saxon, eXist, etc.  So we have to find a more dynamic way to
        // instanciate this map.  But for now, that is enough (simple and
        // decouple static references except only here..., so this is also a
        // good entry point to see what's supported, and each support is
        // isolated in its own class.)
        myHandlers.put("xslt",       new PkgHandlerXslt(myRepo, this));
        myHandlers.put("xquery",     new PkgHandlerXquery(myRepo, this));
        myHandlers.put("xproc",      new PkgHandlerXproc(myRepo, this));
        myHandlers.put("xsd",        new PkgHandlerXsd(myRepo, this));
        myHandlers.put("rng",        new PkgHandlerRng(myRepo, this));
        myHandlers.put("rnc",        new PkgHandlerRnc(myRepo, this));
        myHandlers.put("schematron", new PkgHandlerSchematron(myRepo, this));
        myHandlers.put("nvdl",       new PkgHandlerNvdl(myRepo, this));
        myHandlers.put("saxon",      new PkgHandlerSaxon(myRepo, this));
        myHandlers.put("exist",      new PkgHandlerExist(this));
        myHandlers.put("marklogic",  new PkgHandlerMarklogic(this));
    }

    void installPackage(File pkg, boolean force)
            throws PackageException
    {
        // preconditions
        if ( ! pkg.exists() ) {
            throw new PackageException("Package file does not exist (" + pkg + ")");
        }

        // unzip in temp dir
        File tmpdir;
        try {
            ZipHelper zip = new ZipHelper(pkg);
            tmpdir = zip.unzipToTmpDir();
        }
        catch ( IOException ex ) {
            throw new PackageException("Error unziping the package", ex);
        }
        myInteract.logInfo("Package unziped to " + tmpdir);

        // read package descriptor
        InputStream desc_in;
        try {
            File f = new File(tmpdir, "expath-pkg.xml");
            desc_in = new FileInputStream(f);
        }
        catch ( FileNotFoundException ex ) {
            throw new PackageException("Error opening the package descriptor", ex);
        }

        XMLStreamReader parser;
        try {
            XMLInputFactory factory = XMLInputFactory.newInstance();
            XMLStreamReader reader  = factory.createXMLStreamReader(desc_in);
            StreamFilter    filter  = new WhitespaceFilter();
            parser = factory.createFilteredReader(reader, filter);
        }
        catch ( XMLStreamException ex ) {
            throw new PackageException("Error parsing the package descriptor", ex);
        }

        seekModuleElement(parser);
        do {
            installModule(parser, tmpdir, force);
        }
        while ( ensureNextElement(parser, "module", false) );

        // TODO: Delete tmpdir ???
    }

    private void installModule(XMLStreamReader parser, File tmpdir, boolean force)
            throws PackageException
    {
        String name  = getAttributeValue(parser, "name");
        String ver   = getAttributeValue(parser, "version");
        ensureNextElement(parser, "title", true);
        String title = getElementValue(parser);
        if ( myInteract.ask("Install module " + title + "?", true) ) {
            String versioned = name + "-" + ver;
            versioned = myInteract.ask("Install it to dir", versioned);
            if ( versioned == null ) {
                myInteract.logInfo("Install dir is null, abort install of " + title);
            }
            else {
                File src    = new File(tmpdir, name);
                File target = new File(myRepo.getRoot(), versioned);
                doInstallModule(parser, src, target, force);
            }
        }
    }

    private void doInstallModule(XMLStreamReader parser, File src, File target, boolean force)
            throws PackageException
    {
        // TODO: 'target' here is <repo>/<module-name>.  What about
        // <repo>/.expath-pkg/<module-name> if it exist?  Delete?  And
        // <repo>/.expath-pkg/.saxon/<module-name>...?
        //
        // Actually, we should not delete anything, but really apply a "delete
        // package" action (which in turn will delete stuff, clean up top-level
        // catalog, etc.)
        if ( target.exists() ) {
            if ( force || myInteract.ask("Force override " + target + "?", false) ) {
                deleteDirRecurse(target);
            }
            else {
                throw new PackageException("Destination directory already exists");
            }
        }
        try {
            parser.next();
            while ( parser.getEventType() == XMLStreamConstants.START_ELEMENT ) {
                if ( PKG_NS.equals(parser.getNamespaceURI()) ) {
                    String local = parser.getLocalName();
                    PkgComponentHandler handler = myHandlers.get(local);
                    if ( handler == null ) {
                        throw new PackageException("Unknown component type: " + local);
                    }
                    handler.handleDescription(parser);
                }
                else {
                    // ignore elements not in the EXPath Pkg namespace
                    ignoreElement(parser);
                }
                parser.next();
            }
        }
        catch ( XMLStreamException ex ) {
            throw new PackageException("Error parsing the package descriptor", ex);
        }
        copyDirectory(src, target);
        for ( PkgComponentHandler h : myHandlers.values() ) {
            h.installComponents(target);
        }
    }

    /**
     * Copy {@code src} to {@code target}.
     *
     * Directories are copied recursively.  If {@code target} does not exist, it
     * is created (for both files and dirs.)  If it exist, it is overriden (for
     * files, because dirs are not deleted then recreated, they are just reused.)
     *
     * @param src
     *         The source file or directory.
     *
     * @param target
     *         The target file or directory (must match the type of {@code src}.)
     */
    private void copyDirectory(File src, File target)
            throws PackageException
    {
        if ( src.isDirectory() ) {
            if ( ! target.exists() ) {
                target.mkdir();
            }
            for ( String child : src.list() ) {
                copyDirectory(new File(src, child), new File(target, child));
            }
        }
        else {
            try {
                InputStream  in  = new FileInputStream(src);
                OutputStream out = new FileOutputStream(target);
                byte[] buf = new byte[4096];
                int len;
                while ( (len = in.read(buf)) > 0 ) {
                    out.write(buf, 0, len);
                }
                in.close();
                out.close();
            }
            catch ( IOException ex ) {
                throw new PackageException("Error copying " + src + " to " + target, ex);
            }
        }
    }

    private void deleteDirRecurse(File dir)
    {
        File[] children = dir.listFiles();
        if ( children != null ) {
            for ( File child : children ) {
                deleteDirRecurse(child);
            }
        }
        dir.delete();
    }

    void writeCatalog(Map<String, String> comps, File dir, File target, String kind)
            throws PackageException
    {
        // if no component of that kind, do nothing
        if ( comps.isEmpty() ) {
            return;
        }
        // create the new catalog <repo>/.expath-pkg/<name>/<kind>-catalog.xml
        File subdir = new File(dir, target.getName());
        File catalog1 = new File(subdir, kind + "-catalog.xml");
        try {
            // TODO: Check return value!
            subdir.mkdir();
            OutputStream out = new FileOutputStream(catalog1);
            writeUtf8(out, "<?xml version=\"1.0\"?>\n\n");
            writeUtf8(out, "<catalog xmlns=\"urn:oasis:names:tc:entity:xmlns:xml:catalog\">\n\n");
            for ( Map.Entry<String, String> e : comps.entrySet() ) {
                // system element
                writeUtf8(out, "   <system systemId=\"");
                writeUtf8(out, e.getKey());
                writeUtf8(out, "\"\n");
                writeUtf8(out, "           uri=\"");
                writeUtf8(out, "../../" + target.getName() + "/" + e.getValue());
                writeUtf8(out, "\"/>\n\n");
                // uri element
                writeUtf8(out, "   <uri name=\"");
                writeUtf8(out, e.getKey());
                writeUtf8(out, "\"\n");
                writeUtf8(out, "        uri=\"");
                writeUtf8(out, "../../" + target.getName() + "/" + e.getValue());
                writeUtf8(out, "\"/>\n\n");
            }
            writeUtf8(out, "</catalog>\n");
            out.close();
        }
        catch ( IOException ex ) {
            throw new PackageException("Error writing the catalog: " + catalog1, ex);
        }
        // update the catalog <repo>/.expath-pkg/<kind>-catalog.xml
        // (if does not exist, create an empty one first)
        File catalog2 = new File(dir, kind + "-catalog.xml");
        if ( ! catalog2.exists() ) {
            try {
                OutputStream out = new FileOutputStream(catalog2);
                writeUtf8(out, "<catalog xmlns=\"urn:oasis:names:tc:entity:xmlns:xml:catalog\"/>\n");
                out.close();
            }
            catch ( IOException ex ) {
                throw new PackageException("Error writing an empty catalog: " + catalog2, ex);
            }
        }
        Transformer trans = newXsltTransformer(XSLT_INSTALL_CAT);
        trans.setParameter("catalog", target.getName() + "/" + kind + "-catalog.xml");
        transformXmlFile(trans, dir, catalog2.getName());
    }

    /**
     * Create a new transformer from a resource name pointing to a stylesheet.
     */
    Transformer newXsltTransformer(String style_rsrc)
            throws PackageException
    {
        try {
            ClassLoader loader = Repository.class.getClassLoader();
            InputStream style = loader.getResourceAsStream(style_rsrc);
            TransformerFactory factory = TransformerFactory.newInstance();
            return factory.newTransformer(new StreamSource(style));
        }
        catch ( TransformerException ex ) {
            String msg = "Error compiling a stylesheet from a resource: " + style_rsrc;
            throw new PackageException(msg, ex);
        }
    }

    /**
     * Transform an XML file in place (and make a copy) under {@code dir}.
     */
    void transformXmlFile(Transformer trans, File dir, String name)
            throws PackageException
    {
        try {
            File xml  = new File(dir, name);
            File copy = new File(dir, name + ".COPY");
            if ( copy.exists() ) {
                copy.delete();
            }
            // TODO: check the result
            xml.renameTo(copy);
            // to be able to close the stream afterwards
            OutputStream out = new FileOutputStream(xml);
            trans.transform(new StreamSource(copy), new StreamResult(out));
            out.close();
        }
        catch ( TransformerException ex ) {
            throw new PackageException("Error updating the " + name + " file", ex);
        }
        catch ( IOException ex ) {
            throw new PackageException("Error updating the " + name + " file", ex);
        }
    }

    private void writeUtf8(OutputStream out, String str)
            throws IOException
    {
        out.write(str.getBytes("utf-8"));
    }

    /**
     * Position the parser on the first {@code kg:module} element.
     *
     * The current event must be {@code START_DOCUMENT}, next one must be the
     * root {@code pkg:package} element, then the next one must be a {@code
     * pkg:module} element, which is positionned on.
     */
    private void seekModuleElement(XMLStreamReader parser)
            throws PackageException
    {
        int event = parser.getEventType();
        if ( event != XMLStreamConstants.START_DOCUMENT ) {
            stateError("The current event is not START_DOCUMENT", event);
        }
        ensureNextElement(parser, "package", true);
        ensureNextElement(parser, "module", true);
    }

    boolean ensureNextElement(XMLStreamReader parser, String local_name, boolean error)
            throws PackageException
    {
        try {
            parser.next();
            return ensureElement(parser, local_name, error);
        }
        catch ( XMLStreamException ex ) {
            throw new PackageException("Error parsing the package descriptor", ex);
        }
    }

    boolean ensureElement(XMLStreamReader parser, String local_name, boolean error)
            throws PackageException
    {
        int event = parser.getEventType();
        if ( event != XMLStreamConstants.START_ELEMENT ) {
            return error
                ? stateError("The current event is not START_ELEMENT", event)
                : false;
        }
        if ( ! new QName(PKG_NS, local_name).equals(parser.getName()) ) {
            if ( error ) {
                throw new PackageException("The element is not a pkg:" + local_name);
            }
            else {
                return false;
            }
        }
        return true;
    }

    private boolean stateError(String msg, int event)
            throws PackageException
    {
        String event_name = getEventName(event);
        throw new PackageException(msg + " (" + event_name + ")");
    }

    String getEventName(int event)
            throws PackageException
    {
        switch ( event ) {
            case XMLStreamConstants.ATTRIBUTE:
                return "ATTRIBUTE";
            case XMLStreamConstants.CDATA:
                return "CDATA";
            case XMLStreamConstants.CHARACTERS:
                return "CHARACTERS";
            case XMLStreamConstants.COMMENT:
                return "COMMENT";
            case XMLStreamConstants.DTD:
                return "DTD";
            case XMLStreamConstants.END_DOCUMENT:
                return "END_DOCUMENT";
            case XMLStreamConstants.END_ELEMENT:
                return "END_ELEMENT";
            case XMLStreamConstants.ENTITY_DECLARATION:
                return "ENTITY_DECLARATION";
            case XMLStreamConstants.ENTITY_REFERENCE:
                return "ENTITY_REFERENCE";
            case XMLStreamConstants.NAMESPACE:
                return "NAMESPACE";
            case XMLStreamConstants.NOTATION_DECLARATION:
                return "NOTATION_DECLARATION";
            case XMLStreamConstants.PROCESSING_INSTRUCTION:
                return "PROCESSING_INSTRUCTION";
            case XMLStreamConstants.SPACE:
                return "SPACE";
            case XMLStreamConstants.START_DOCUMENT:
                return "START_DOCUMENT";
            case XMLStreamConstants.START_ELEMENT:
                return "START_ELEMENT";
            default:
                throw new PackageException("Unknown event code: " + event);
        }
    }

    private String getAttributeValue(XMLStreamReader parser, String name)
            throws PackageException
    {
        String value = parser.getAttributeValue(null, name);
        if ( value == null ) {
            throw new PackageException("The element does not have an attribute " + name);
        }
        return value;
    }

    String getElementValue(XMLStreamReader parser)
            throws PackageException
    {
        return accumulateStringValue(parser);
    }

    private String accumulateStringValue(XMLStreamReader parser)
            throws PackageException
    {
        StringBuilder buf = new StringBuilder();
        try {
            int event;
            while ( (event = parser.next()) != XMLStreamConstants.END_ELEMENT ) {
                if ( event != XMLStreamConstants.CHARACTERS ) {
                    stateError("Current event is not CHARACTERS", event);
                }
                buf.append(parser.getTextCharacters(), parser.getTextStart(), parser.getTextLength());
            }
        }
        catch ( XMLStreamException ex ) {
            throw new PackageException("Error parsing the package descriptor", ex);
        }
        return buf.toString();
    }

    /**
     * The parser's current event must be START_ELEMENT.
     *
     * Ignore all this element end stops on the corresponding END_ELEMENT event.
     */
    void ignoreElement(XMLStreamReader parser)
            throws PackageException
    {
        // ignore this element entirely
        int opened = 1;
        while ( opened > 0 ) {
            try {
                parser.next();
                if ( parser.getEventType() == XMLStreamConstants.START_ELEMENT ) {
                    ++opened;
                }
                else if ( parser.getEventType() == XMLStreamConstants.END_ELEMENT ) {
                    --opened;
                }
            }
            catch ( XMLStreamException ex ) {
                throw new PackageException("Error reading the package descriptor", ex);
            }
        }
    }

    static final String PKG_NS = "http://expath.org/mod/expath-pkg";
    static final String XSLT_INSTALL_CAT = "org/expath/pkg/repo/catalog-install.xsl";

    private Repository myRepo;
    private UserInteractionStrategy myInteract;
    private Map<String, PkgComponentHandler> myHandlers;

    /**
     * Filter out whitespace text nodes, as well as comments and PIs.
     *
     * TODO: Mmh... Could maybe use nextTag() instead of next() on the parser
     * in order to achieve the same purpose?
     */
    private static class WhitespaceFilter
            implements StreamFilter
    {
        @Override
        public boolean accept(XMLStreamReader parser)
        {
            int event = parser.getEventType();
            return event != XMLStreamConstants.COMMENT
              &&   event != XMLStreamConstants.PROCESSING_INSTRUCTION
              &&   event != XMLStreamConstants.SPACE
              && ( event != XMLStreamConstants.CHARACTERS || ! parser.isWhiteSpace() );
        }
    }
}


/* ------------------------------------------------------------------------ */
/*  DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS COMMENT.               */
/*                                                                          */
/*  The contents of this file are subject to the Mozilla Public License     */
/*  Version 1.0 (the "License"); you may not use this file except in        */
/*  compliance with the License. You may obtain a copy of the License at    */
/*  http://www.mozilla.org/MPL/.                                            */
/*                                                                          */
/*  Software distributed under the License is distributed on an "AS IS"     */
/*  basis, WITHOUT WARRANTY OF ANY KIND, either express or implied.  See    */
/*  the License for the specific language governing rights and limitations  */
/*  under the License.                                                      */
/*                                                                          */
/*  The Original Code is: all this file.                                    */
/*                                                                          */
/*  The Initial Developer of the Original Code is Florent Georges.          */
/*                                                                          */
/*  Contributor(s): none.                                                   */
/* ------------------------------------------------------------------------ */
